Key

scroll

Key Blog

  • Key 홈페이지>
  • 블로그>
  • [ue5] c++でactor poolを作成してパフォーマンスを最適化する方法
  • [UE5] C++로 Actor Pool 만들기 – 성능 최적화를 위한 방법

    @kiikey4(Key Zhao)

    [UE5] C++로 Actor Pool 만들기 – 성능 최적화를 위한 방법

    마지막 업데이트 날짜 2025년 8월 6일

    게시일 2025년 8월 5일

    0

    이 게시물은 한국어를 지원하지 않습니다.

    Overview

    Spawning objects can be performance-intensive, especially when spawning a large number of them. To address this, we can implement an object pool—in Unreal Engine, an Actor Pool is more appropriate. The idea is to pre-spawn actors during loading, then deactivate and hide them. When we need to spawn one, we retrieve it from the pool and activate it. When we're done with it, we return it to the pool instead of destroying it, so it can be reused.

    There are many object pool or actor pool plugins available on Fab, but actor pooling is actually quite easy to implement. So, I decided to create one myself.

    My version of the actor pool supports multiplayer and runs only on the server side because clients typically do not spawn replicated gameplay actors directly.

    📚 Further Reading on Object Pooling

    Environment

    • Unreal Engine 5.6.0
    • Windows 11 Pro

    Main Content

    Normal Spawning Performance

    Before the implementation, I measured the performance of spawning my projectile so I can compare it later.

    Max Time of ServerSpawnProjectile (363.3 µs) 725_B4ActorPool_Max_2025-08-05_01h36_42_dfku1w

    As we can see, within SpawnActor (359.2 µs), the engine processes ConstructObject (78.8 µs) and RegisterAllComponents (122.9 µs), which can be avoided by using an actor pool. Only BeginPlay is needed for the custom logic.

    💡 By the way, the reason for the Blueprint Time (362.4 - 359.2 = 3.2 µs) cost is that ServerSpawnProjectile is marked as a UFUNCTION(), so the engine launches the Blueprint virtual machine to process it, even though it's a C++ function. This is unnecessary overhead (engine issue).

    As we can see, when an actor is spawned, a lot of initialization takes place, which impacts performance.

    This is the flowchart of spawning actor and despawn actor in actor pool.

    flowchart_en_xawhqa

    Create an Actor Pool in C++

    Create an IPoolableInterface for Poolable Actors

    We create an interface that will be called when an actor is activated from or deactivated to the pool, allowing us to implement custom logic. Since pooled actors do not trigger BeginPlay or EndPlay, we need to use this interface to manage lifecycle behavior manually.

    title=YourProject/Core/Interface/IPoolableInterface.h
    1#pragma once 2 3#include "CoreMinimal.h" 4#include "UObject/Interface.h" 5#include "IPoolableInterface.generated.h" 6 7class UActorPool; 8 9UINTERFACE(MinimalAPI) 10class UPoolableInterface : public UInterface 11{ 12 GENERATED_BODY() 13}; 14 15/** 16 * Interface for actors that can be managed by the actor pool system. 17 * Provides callbacks for when actors are activated from or returned to the pool. 18 */ 19class YOUR_API IPoolableInterface 20{ 21 GENERATED_BODY() 22 23public: 24 /** 25 * Called when an actor is retrieved from the pool and activated for gameplay. 26 * Use this to start timers, initialize state, and prepare for active use. 27 * @param InActorPool The pool this actor belongs to 28 * @param Location The world location to spawn at 29 * @param Rotation The world rotation to spawn with 30 * @param SpawnParameters Additional spawn parameters 31 */ 32 virtual void OnActivateFromPool(UActorPool* InActorPool, const FVector& Location, const FRotator& Rotation, const FActorSpawnParameters& SpawnParameters) = 0; 33 34 /** 35 * Called when an actor is being returned to the pool and deactivated. 36 * Use this to clear timers, reset state, and prepare for pool storage. 37 */ 38 virtual void OnDeactivateFromPool() = 0; 39};

    Create the ActorPool class

    We use a UObject to implement the pool. While many developers and plugins use centralized systems like managers, subsystems, or ActorComponent (which can only be attached to Actor), I prefer using a UObject for its flexibility. A UObject-based pool can be owned and managed by any class, such as an actor, game mode, subsystem, or component. It is easy to integrate where needed. This decentralized approach keeps the design simple, easier to debug, and more configurable (e.g., setting prewarm counts). It’s also more decoupled, promotes reuse, and comes with less overhead compared to actor-based components or global subsystems.

    Header file:

    YourProject/Core/Utility/Object/ActorPool.h
    1 2#pragma once 3 4#include "CoreMinimal.h" 5#include "Engine/World.h" 6#include "UObject/Object.h" 7#include "ActorPool.generated.h" 8 9/** 10 * 11 */ 12UCLASS() 13class YOUR_API UActorPool : public UObject 14{ 15 GENERATED_BODY() 16 17public: 18 UFUNCTION(BlueprintCallable, Category = "Actor Pool") 19 void InitializePool(TSubclassOf<AActor> InActorClass, int32 InPrewarmCount = 5); 20 21 UFUNCTION(BlueprintCallable, Category = "Actor Pool") 22 void ReturnToPool(AActor* Actor); 23 24public: 25 AActor* TrySpawnPooledActor(const FVector& Location, const FRotator& Rotation, 26 const FActorSpawnParameters& SpawnParameters = FActorSpawnParameters()); 27 28 FORCEINLINE bool IsEmpty() const 29 { 30 return PooledActors.Num() == 0; 31 } 32 33 FORCEINLINE int32 GetSize() const 34 { 35 return PooledActors.Num(); 36 } 37 38 FORCEINLINE void PushActor(AActor* Actor); 39 40 AActor* PopActor(); 41 42protected: 43 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Actor Pool") 44 TSubclassOf<AActor> ActorClass; 45 46 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Actor Pool", meta = (ClampMin = "1")) 47 int32 PrewarmCount = 5; 48 49 UPROPERTY() 50 TArray<TObjectPtr<AActor>> PooledActors; 51 52private: 53 void PrewarmPool(); 54 void ActivatePooledActor(AActor* Actor, const FVector& Location, const FRotator& Rotation, 55 const FActorSpawnParameters& SpawnParameters); 56 void DeactivatePooledActor(AActor* Actor); 57 58private: 59 // Stats 60 int32 PoolMisses = 0; 61}; 62

    💡 Important: Make sure to add UPROPERTY() to PooledActors. Otherwise, Unreal's garbage collector may remove the actors unexpectedly, causing hard-to-debug issues 😨.

    cpp file:

    YourProject/Core/Utility/Object/ActorPool.cpp
    1 2 3#include "ActorPool.h" 4 5#include <YourProject/Core/Interface/IPoolableInterface.h> 6 7DEFINE_LOG_CATEGORY_STATIC(LogActorPool, Log, All); 8 9void UActorPool::InitializePool(TSubclassOf<AActor> InActorClass, int32 InPrewarmCount) 10{ 11 if (!IsValid(InActorClass)) 12 { 13 UE_LOG(LogActorPool, Error, TEXT("UActorPool::InitializePool - Invalid Actor Class")); 14 return; 15 } 16 17 ActorClass = InActorClass; 18 PrewarmCount = InPrewarmCount; 19 PooledActors.Empty(); 20 PrewarmPool(); 21} 22 23void UActorPool::PrewarmPool() 24{ 25 if (!IsValid(ActorClass) || PrewarmCount <= 0) 26 { 27 UE_LOG(LogActorPool, Warning, TEXT("UActorPool::PrewarmPool - Invalid Actor Class or Prewarm Count")); 28 return; 29 } 30 31 UWorld* World = GetWorld(); 32 if (!IsValid(World)) 33 { 34 UE_LOG(LogActorPool, Error, TEXT("UActorPool::PrewarmPool - Invalid World")); 35 return; 36 } 37 for (int32 i = 0; i < PrewarmCount; ++i) 38 { 39 FActorSpawnParameters SpawnParams; 40 SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; 41 42 AActor* NewActor = World->SpawnActor<AActor>(ActorClass, FVector::ZeroVector, FRotator::ZeroRotator, 43 SpawnParams); 44 if (IsValid(NewActor)) 45 { 46 DeactivatePooledActor(NewActor); 47 PushActor(NewActor); 48 UE_LOG(LogActorPool, Log, TEXT("Prewarmed actor %s (%d/%d)"), 49 *NewActor->GetName(), i + 1, PrewarmCount); 50 } 51 else 52 { 53 UE_LOG(LogActorPool, Error, TEXT("UActorPool::PrewarmPool - Failed to spawn actor %s"), 54 *ActorClass->GetName()); 55 } 56 } 57} 58 59AActor* UActorPool::TrySpawnPooledActor(const FVector& Location, const FRotator& Rotation, 60 const FActorSpawnParameters& SpawnParameters) 61{ 62 if (!IsValid(ActorClass) || PrewarmCount <= 0) 63 { 64 UE_LOG(LogActorPool, Warning, 65 TEXT("UActorPool::TrySpawnPooledActor - Invalid Actor Class or Prewarm Count")); 66 return nullptr; 67 } 68 69 70 UWorld* World = GetWorld(); 71 if (!IsValid(World)) 72 { 73 UE_LOG(LogActorPool, Warning, TEXT("TrySpawnPooledActor: Invalid World")); 74 return nullptr; 75 } 76 77 if (AActor* PooledActor = PopActor()) 78 { 79 ActivatePooledActor(PooledActor, Location, Rotation, SpawnParameters); 80 UE_LOG(LogActorPool, Log, TEXT("Spawned pooled actor: %s at location: %s, rotation: %s"), 81 *PooledActor->GetName(), *Location.ToString(), *Rotation.ToString()); 82 return PooledActor; 83 } 84 85 PoolMisses++; 86 UE_LOG(LogActorPool, Warning, 87 TEXT("Pool empty for class: %s, falling back to spawn new actor. Pool Misses: %d, Prewarm Count: %d"), 88 *ActorClass->GetName(), PoolMisses, PrewarmCount); 89 90 AActor* NewActor = World->SpawnActor<AActor>(ActorClass, Location, Rotation, SpawnParameters); 91 if (IsValid(NewActor)) 92 { 93 ActivatePooledActor(NewActor, Location, Rotation, SpawnParameters); 94 } 95 return NewActor; 96} 97 98void UActorPool::ReturnToPool(AActor* Actor) 99{ 100 if (!IsValid(Actor)) 101 { 102 return; 103 } 104 105 // Check authority for network safety 106 if (Actor->GetLocalRole() != ROLE_Authority) 107 { 108 UE_LOG(LogActorPool, Error, TEXT("Not Authority, cannot return actor to pool: %s"), *Actor->GetName()); 109 return; 110 } 111 112 DeactivatePooledActor(Actor); 113 PushActor(Actor); 114} 115 116void UActorPool::ActivatePooledActor(AActor* Actor, const FVector& Location, const FRotator& Rotation, 117 const FActorSpawnParameters& SpawnParameters) 118{ 119 if (!IsValid(Actor)) 120 { 121 return; 122 } 123 124 // Cache root component lookup to avoid repeated virtual calls 125 UPrimitiveComponent* RootPrimitive = Cast<UPrimitiveComponent>(Actor->GetRootComponent()); 126 127 // Batch actor transform and ownership changes 128 Actor->SetActorLocationAndRotation(Location, Rotation); 129 Actor->SetOwner(SpawnParameters.Owner); 130 Actor->SetInstigator(SpawnParameters.Instigator); 131 132 // Batch actor state changes 133 Actor->SetActorHiddenInGame(false); 134 Actor->SetActorEnableCollision(true); 135 Actor->SetActorTickEnabled(true); 136 137 // Reset physics state if primitive component exists 138 if (RootPrimitive) 139 { 140 RootPrimitive->SetAllPhysicsLinearVelocity(FVector::ZeroVector); 141 RootPrimitive->SetAllPhysicsAngularVelocityInDegrees(FVector::ZeroVector); 142 } 143 144 Actor->Reset(); 145 146 // Call poolable interface if implemented 147 if (IPoolableInterface* PoolableInterface = Cast<IPoolableInterface>(Actor)) 148 { 149 PoolableInterface->OnActivateFromPool(this, Location, Rotation, SpawnParameters); 150 } 151} 152 153void UActorPool::DeactivatePooledActor(AActor* Actor) 154{ 155 // Call poolable interface first to allow cleanup before state changes 156 if (IPoolableInterface* PoolableInterface = Cast<IPoolableInterface>(Actor)) 157 { 158 PoolableInterface->OnDeactivateFromPool(); 159 } 160 161 // Cache root component lookup to avoid repeated virtual calls 162 UPrimitiveComponent* RootPrimitive = Cast<UPrimitiveComponent>(Actor->GetRootComponent()); 163 164 // Batch actor state changes 165 Actor->SetActorHiddenInGame(true); 166 Actor->SetActorEnableCollision(false); 167 Actor->SetActorTickEnabled(false); 168 169 // Reset physics state if primitive component exists 170 if (RootPrimitive) 171 { 172 RootPrimitive->SetAllPhysicsLinearVelocity(FVector::ZeroVector); 173 RootPrimitive->SetAllPhysicsAngularVelocityInDegrees(FVector::ZeroVector); 174 } 175 176 // Clear ownership references 177 Actor->SetOwner(nullptr); 178 Actor->SetInstigator(nullptr); 179} 180 181void UActorPool::PushActor(AActor* Actor) 182{ 183 PooledActors.Add(Actor); 184} 185 186AActor* UActorPool::PopActor() 187{ 188 while (IsEmpty() == false) 189 { 190 if (AActor* Actor = PooledActors.Pop(); IsValid(Actor)) 191 { 192 return Actor; 193 } 194 } 195 return nullptr; 196}

    if there are not enough pooled actors, the pool fallback to normal spawning, and dynamically expands the pool. It also counts the pool misses and log the warning so we can adjust the prewarm pool size accordingly.

    Example Usage

    Implement this interface in your poolable actor and add your custom logic to OnActivateFromPool() and OnDeactivateFromPool().

    For example, I have a projectile class that holds a pointer to its ActorPool, allowing it to return itself to the pool instead of being destroyed.

    Header:

    ProjectileBase.h
    1 2// ... 3 4public: 5// IPoolableInterface 6 virtual void OnActivateFromPool(UActorPool* InActorPool, const FVector& Location, const FRotator& Rotation, const FActorSpawnParameters& SpawnParameters) override; 7 virtual void OnDeactivateFromPool() override; 8 9 void ReturnToPoolOrDestroy(); 10 11protected: 12UPROPERTY() 13 TObjectPtr<UActorPool> ActorPool;
    ProjectileBase.cpp
    1 2void AProjectileBase::OnActivateFromPool(UActorPool* InActorPool, const FVector& Location, const FRotator& Rotation, 3 const FActorSpawnParameters& SpawnParameters) 4{ 5 ActorPool = InActorPool; 6 7 // Recalculate projectile velocity based on rotation 8 if (UProjectileMovementComponent* MovementComp = GetProjectileMovement()) 9 { 10 // Ensure the movement component has the correct UpdatedComponent 11 if (CollisionComp) 12 { 13 MovementComp->SetUpdatedComponent(CollisionComp); 14 } 15 16 // Calculate velocity based on spawn rotation, not actor forward (which might be wrong for pooled actors) 17 FVector InitialVelocity = Rotation.Vector() * MovementComp->InitialSpeed; 18 19 MovementComp->StartSimulating(InitialVelocity); 20 } 21 else 22 { 23 UE_LOG(LogTemp, Error, TEXT("OnPoolActivate - No movement component found!")); 24 } 25} 26 27void AProjectileBase::OnDeactivateFromPool() 28{ 29 if (UProjectileMovementComponent* MovementComp = GetProjectileMovement()) 30 { 31 MovementComp->StopSimulating(FHitResult()); 32 33 MovementComp->UpdateComponentVelocity(); 34 } 35} 36 37 38void AProjectileBase::ReturnToPoolOrDestroy() 39{ 40 if (!HasAuthority()) 41 { 42 return; 43 } 44 45 if (!IsValid(ActorPool)) 46 { 47 UE_LOG(LogTemp, Warning, TEXT("AProjectileBase::ReturnToPoolOrDestroy - No ActorPool set! Destroying actor instead.")); 48 Destroy(); 49 return; 50 } 51 52 ActorPool->ReturnToPool(this); 53 54}

    So I call ReturnToPoolOrDestroy() instead of Destroy().

    In the class that spawns the poolable actor, declare the ActorPool in the header file. and store the poolable actor class for spawn.

    YourClass.h
    1class UActorPool; 2 3//... 4{ 5 6//... 7 8protected: 9 10//... 11 UPROPERTY(EditDefaultsOnly, Category=Projectile) 12 TSubclassOf<class APawProjectileBase> ProjectileClass; 13 14 UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Actor Pool") 15 TObjectPtr<UActorPool> ProjectilePool; 16 17 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Actor Pool", meta = (ClampMin = "1")) 18 int32 PrewarmCount = 3; 19}

    Init the actor pool, give the class and prewarm count to the pool.

    YourClass.cpp
    1 2// can be during your custom Game Loading 3// or just put in BeginPlay() 4ProjectilePool->InitializePool(ProjectileClass, PrewarmCount); 5// ...

    Spawn the pooled actor

    YourClass.cpp
    1// ... 2if (const UWorld* World = GetWorld(); IsValid(World)) 3 { 4 //Set Spawn Collision Handling Override 5 FActorSpawnParameters ActorSpawnParams; 6 7 // Specify the spawn params 8 9 // ActorSpawnParams.SpawnCollisionHandlingOverride = 10 // ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn; 11 // ActorSpawnParams.Owner = FPSPlayer; 12 // ActorSpawnParams.Instigator = FPSPlayer; 13 AActor* SpawnProjectile = ProjectilePool->TrySpawnPooledActor( 14 SpawnLocation, SpawnRotation, ActorSpawnParams); 15 } 16//...

    Done!

    This Actor Pool is also useful for pooling other gameplay objects such as AI enemies, pickups, whatever it is Actor.

    Result

    Before Actor Pool Max Time of ServerSpawnProjectile (363.3 µs) 725_B4ActorPool_Max_2025-08-05_01h36_42_dfku1w

    After Actor Pool Max Time of ServerSpawnProjectile (187.2 µs) 725_AfterActorPool_Max_2025-08-05_01h36_42_adqvro

    The ConstructObject and RegisterAllComponents is avoided using Actor Pool, and I moved the BeginPlay logic to OnActivateFromPool and only that is needed.

    MetricBefore (B4)After (AFT)Improvement
    Min Time285.6 μs99.2 μs~2.88× speedup
    Max Time363.3 μs187.2 μs~1.94× speedup

    Before using the Actor Pool, spawning a projectile cost 285.6–363.3 μs.
    After implementing the Actor Pool, activating a pooled projectile only takes 99.2–187.2 μs.
    The heavy spawn cost is preloaded during the prewarm phase, resulting in an effective runtime speedup of ~1.94× to ~2.88×.

    Conclusion

    Using an Actor Pool (Object Pool) in Unreal Engine 5 with C++ is a powerful way to reduce runtime overhead and improve performance, especially for frequently spawned and destroyed actors like projectiles, effects, or enemies. By implementing the pool with an UObject, you gain flexibility, decoupling, and better reusability across your game architecture.

    Whether you manage the pool from a game instance, game mode, subsystem, actor, or component, this decentralized approach is clean, easy to debug, and highly customizable.

    If I'm wrong about anything, please feel free to correct me in the comments.

    References

    0

    댓글

    댓글이 없습니다

    느낌을 댓글로 남겨보세요